[1mdiff --git a/src/audio/buffer_meter.rs b/src/audio/buffer_meter.rs[m
[1mindex 973109a..62624fe 100644[m
[1m--- a/src/audio/buffer_meter.rs[m
[1m+++ b/src/audio/buffer_meter.rs[m
[36m@@ -16,6 +16,8 @@[m [mpub(super) struct BufferStatusMeter {[m
struct BufferStatusState {[m
historical_velocity: f32,[m
last_consumed_at: Option<Instant>,[m
[32m+[m[32m last_sent_percent: Option<u8>,[m
[32m+[m[32m last_sent_seconds: Option<u32>,[m
}[m
[m
impl BufferStatusMeter {[m
[36m@@ -27,6 +29,8 @@[m [mimpl BufferStatusMeter {[m
state: Mutex::new(BufferStatusState {[m
historical_velocity: fallback_velocity,[m
last_consumed_at: None,[m
[32m+[m[32m last_sent_percent: None,[m
[32m+[m[32m last_sent_seconds: None,[m
}),[m
}[m
}[m
[36m@@ -37,17 +41,15 @@[m [mimpl BufferStatusMeter {[m
capacity: usize,[m
status_tx: &mpsc::Sender<AudioStatus>,[m
) {[m
[31m- let (percent, seconds) = {[m
[31m- let state = self.state.lock().unwrap();[m
[31m- buffer_status_from_velocity([m
[31m- len,[m
[31m- capacity,[m
[31m- state.historical_velocity,[m
[31m- self.fallback_velocity,[m
[31m- )[m
[31m- };[m
[32m+[m[32m let mut state = self.state.lock().unwrap();[m
[32m+[m[32m let (percent, seconds) = buffer_status_from_velocity([m
[32m+[m[32m len,[m
[32m+[m[32m capacity,[m
[32m+[m[32m state.historical_velocity,[m
[32m+[m[32m self.fallback_velocity,[m
[32m+[m[32m );[m
[m
[31m- send_buffer_status(status_tx, percent, seconds);[m
[32m+[m[32m send_buffer_status_if_changed(status_tx, &mut state, percent, seconds);[m
}[m
[m
pub(super) fn record_consumed([m
[36m@@ -61,40 +63,50 @@[m [mimpl BufferStatusMeter {[m
return;[m
}[m
[m
[31m- let (percent, seconds) = {[m
[31m- let mut state = self.state.lock().unwrap();[m
[31m- let now = Instant::now();[m
[31m- let delta_t = state[m
[31m- .last_consumed_at[m
[31m- .replace(now)[m
[31m- .map(|last| now.saturating_duration_since(last).as_secs_f32())[m
[31m- .unwrap_or(0.0);[m
[31m-[m
[31m- if delta_t >= MIN_MEASURED_SECONDS {[m
[31m- buffer_level_status_adaptive_with_fallback([m
[31m- len,[m
[31m- capacity,[m
[31m- &mut state.historical_velocity,[m
[31m- bytes_read,[m
[31m- delta_t,[m
[31m- self.fallback_velocity,[m
[31m- )[m
[31m- } else {[m
[31m- buffer_status_from_velocity([m
[31m- len,[m
[31m- capacity,[m
[31m- state.historical_velocity,[m
[31m- self.fallback_velocity,[m
[31m- )[m
[31m- }[m
[32m+[m[32m let mut state = self.state.lock().unwrap();[m
[32m+[m[32m let now = Instant::now();[m
[32m+[m[32m let delta_t = state[m
[32m+[m[32m .last_consumed_at[m
[32m+[m[32m .replace(now)[m
[32m+[m[32m .map(|last| now.saturating_duration_since(last).as_secs_f32())[m
[32m+[m[32m .unwrap_or(0.0);[m
[32m+[m
[32m+[m[32m let (percent, seconds) = if delta_t >= MIN_MEASURED_SECONDS {[m
[32m+[m[32m buffer_level_status_adaptive_with_fallback([m
[32m+[m[32m len,[m
[32m+[m[32m capacity,[m
[32m+[m[32m &mut state.historical_velocity,[m
[32m+[m[32m bytes_read,[m
[32m+[m[32m delta_t,[m
[32m+[m[32m self.fallback_velocity,[m
[32m+[m[32m )[m
[32m+[m[32m } else {[m
[32m+[m[32m buffer_status_from_velocity([m
[32m+[m[32m len,[m
[32m+[m[32m capacity,[m
[32m+[m[32m state.historical_velocity,[m
[32m+[m[32m self.fallback_velocity,[m
[32m+[m[32m )[m
};[m
[m
[31m- send_buffer_status(status_tx, percent, seconds);[m
[32m+[m[32m send_buffer_status_if_changed(status_tx, &mut state, percent, seconds);[m
}[m
}[m
[m
[31m-fn send_buffer_status(status_tx: &mpsc::Sender<AudioStatus>, percent: u8, seconds: u32) {[m
[31m- let _ = status_tx.send(AudioStatus::BufferLevel { percent, seconds });[m
[32m+[m[32mfn send_buffer_status_if_changed([m
[32m+[m[32m status_tx: &mpsc::Sender<AudioStatus>,[m
[32m+[m[32m state: &mut BufferStatusState,[m
[32m+[m[32m percent: u8,[m
[32m+[m[32m seconds: u32,[m
[32m+[m[32m) {[m
[32m+[m[32m let changed =[m
[32m+[m[32m state.last_sent_percent != Some(percent) || state.last_sent_seconds != Some(seconds);[m
[32m+[m
[32m+[m[32m if changed {[m
[32m+[m[32m state.last_sent_percent = Some(percent);[m
[32m+[m[32m state.last_sent_seconds = Some(seconds);[m
[32m+[m[32m let _ = status_tx.send(AudioStatus::BufferLevel { percent, seconds });[m
[32m+[m[32m }[m
}[m
[m
fn buffer_level_status_adaptive_with_fallback([m
[36m@@ -217,4 +229,56 @@[m [mmod tests {[m
assert!(second > 8_000.0);[m
assert!(second < first);[m
}[m
[32m+[m
[32m+[m[32m #[test][m
[32m+[m[32m fn buffer_status_sends_first_measurement() {[m
[32m+[m[32m let meter = BufferStatusMeter::new(16_000);[m
[32m+[m[32m let (tx, rx) = std::sync::mpsc::channel();[m
[32m+[m
[32m+[m[32m meter.report_fill_level(160_000, 1_000_000, &tx);[m
[32m+[m
[32m+[m[32m assert!(matches!([m
[32m+[m[32m rx.try_recv(),[m
[32m+[m[32m Ok(AudioStatus::BufferLevel {[m
[32m+[m[32m percent: 16,[m
[32m+[m[32m seconds: 10[m
[32m+[m[32m })[m
[32m+[m[32m ));[m
[32m+[m[32m }[m
[32m+[m
[32m+[m[32m #[test][m
[32m+[m[32m fn buffer_status_suppresses_identical_measurements() {[m
[32m+[m[32m let meter = BufferStatusMeter::new(16_000);[m
[32m+[m[32m let (tx, rx) = std::sync::mpsc::channel();[m
[32m+[m
[32m+[m[32m meter.report_fill_level(160_000, 1_000_000, &tx);[m
[32m+[m[32m meter.report_fill_level(160_000, 1_000_000, &tx);[m
[32m+[m
[32m+[m[32m assert!(rx.try_recv().is_ok());[m
[32m+[m[32m assert!(rx.try_recv().is_err());[m
[32m+[m[32m }[m
[32m+[m
[32m+[m[32m #[test][m
[32m+[m[32m fn buffer_status_sends_when_percent_changes() {[m
[32m+[m[32m let meter = BufferStatusMeter::new(16_000);[m
[32m+[m[32m let (tx, rx) = std::sync::mpsc::channel();[m
[32m+[m
[32m+[m[32m meter.report_fill_level(160_000, 1_000_000, &tx);[m
[32m+[m[32m meter.report_fill_level(170_000, 1_000_000, &tx);[m
[32m+[m
[32m+[m[32m assert!(rx.try_recv().is_ok());[m
[32m+[m[32m assert!(rx.try_recv().is_ok());[m
[32m+[m[32m }[m
[32m+[m
[32m+[m[32m #[test][m
[32m+[m[32m fn buffer_status_sends_when_seconds_changes() {[m
[32m+[m[32m let meter = BufferStatusMeter::new(16_000);[m
[32m+[m[32m let (tx, rx) = std::sync::mpsc::channel();[m
[32m+[m
[32m+[m[32m meter.report_fill_level(160_000, 1_000_000, &tx);[m
[32m+[m[32m meter.report_fill_level(168_000, 1_000_000, &tx);[m
[32m+[m
[32m+[m[32m assert!(rx.try_recv().is_ok());[m
[32m+[m[32m assert!(rx.try_recv().is_ok());[m
[32m+[m[32m }[m
}[m
[1mdiff --git a/src/ui/mod.rs b/src/ui/mod.rs[m
[1mindex 26719ce..dfaee5e 100644[m
[1m--- a/src/ui/mod.rs[m
[1m+++ b/src/ui/mod.rs[m
[36m@@ -9,18 +9,26 @@[m [mpub mod stations;[m
pub mod theme;[m
[m
use ratatui::prelude::*;[m
[31m-use ratatui::widgets::{Block, Paragraph};[m
[32m+[m[32muse ratatui::widgets::{Block, Borders, Paragraph};[m
[m
use crate::app::{App, InputMode, LayoutMode};[m
[m
[32m+[m[32mconst MIN_REQUIRED_WIDTH: u16 = 80;[m
[32m+[m[32mconst MIN_REQUIRED_HEIGHT: u16 = 24;[m
[32m+[m
/// Render the entire UI. Root layout composition.[m
pub fn draw(frame: &mut Frame, app: &App) {[m
let size = frame.area();[m
[m
[31m- // Fill background with pure black[m
[31m- let bg = Block::default().style(Style::default().bg(theme::bg()));[m
[32m+[m[32m // Fill background with the active theme before any layout work.[m
[32m+[m[32m let bg = Block::default().style(theme::clear());[m
frame.render_widget(bg, size);[m
[m
[32m+[m[32m if is_compact_terminal(size) {[m
[32m+[m[32m render_compact_terminal_warning(frame, size);[m
[32m+[m[32m return;[m
[32m+[m[32m }[m
[32m+[m
let is_searching = app.input_mode == InputMode::Search;[m
[m
// Main vertical layout: header | separator | main content split | separator | controls[m
[36m@@ -129,3 +137,28 @@[m [mpub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {[m
])[m
.split(popup_layout[1])[1][m
}[m
[32m+[m
[32m+[m[32m#[cfg(test)][m
[32m+[m[32mmod tests {[m
[32m+[m[32m use super::*;[m
[32m+[m
[32m+[m[32m #[test][m
[32m+[m[32m fn compact_terminal_rejects_width_below_minimum() {[m
[32m+[m[32m assert!(is_compact_terminal(Rect::new(0, 0, 79, 24)));[m
[32m+[m[32m }[m
[32m+[m
[32m+[m[32m #[test][m
[32m+[m[32m fn compact_terminal_rejects_height_below_minimum() {[m
[32m+[m[32m assert!(is_compact_terminal(Rect::new(0, 0, 80, 23)));[m
[32m+[m[32m }[m
[32m+[m
[32m+[m[32m #[test][m
[32m+[m[32m fn compact_terminal_accepts_exact_minimum() {[m
[32m+[m[32m assert!(!is_compact_terminal(Rect::new(0, 0, 80, 24)));[m
[32m+[m[32m }[m
[32m+[m
[32m+[m[32m #[test][m
[32m+[m[32m fn compact_terminal_accepts_larger_terminal() {[m
[32m+[m[32m assert!(!is_compact_terminal(Rect::new(0, 0, 120, 40)));[m
[32m+[m[32m }[m
[32m+[m[32m}[m
[1mdiff --git a/src/ui/stations.rs b/src/ui/stations.rs[m
[1mindex 367dde4..bf26025 100644[m
[1m--- a/src/ui/stations.rs[m
[1m+++ b/src/ui/stations.rs[m
[36m@@ -94,7 +94,12 @@[m [mpub fn render(frame: &mut Frame, area: Rect, app: &App) {[m
let fixed_width =[m
visible_len(cursor) + visible_len(save_marker) + visible_len(&meta_chip) + 2;[m
let name_width = row_width.saturating_sub(fixed_width).max(8);[m
[31m- let name = truncate_with_ellipsis(station.name.as_str(), name_width);[m
[32m+[m[32m let search_query = if app.input_mode == InputMode::Search {[m
[32m+[m[32m Some(app.search_query.as_str())[m
[32m+[m[32m } else {[m
[32m+[m[32m None[m
[32m+[m[32m };[m
[32m+[m[32m let name = truncate_station_name(station.name.as_str(), search_query, name_width);[m
let padding = row_width.saturating_sub([m
visible_len(cursor)[m
+ visible_len(save_marker)[m
[36m@@ -121,7 +126,7 @@[m [mpub fn render(frame: &mut Frame, area: Rect, app: &App) {[m
.borders(Borders::ALL)[m
.border_style(theme::border())[m
.border_type(ratatui::widgets::BorderType::Rounded)[m
[31m- .style(Style::default().bg(theme::bg())),[m
[32m+[m[32m .style(theme::clear()),[m
)[m
.highlight_style(theme::selected())[m
.highlight_symbol("");[m
[36m@@ -202,6 +207,69 @@[m [mfn empty_fallback<'a>(value: &'a str, fallback: &'a str) -> &'a str {[m
}[m
}[m
[m
[32m+[m[32mfn truncate_station_name(value: &str, query: Option<&str>, max_chars: usize) -> String {[m
[32m+[m[32m match query.map(str::trim).filter(|query| !query.is_empty()) {[m
[32m+[m[32m Some(query) => adaptive_search_truncate(value, query, max_chars),[m
[32m+[m[32m None => truncate_with_ellipsis(value, max_chars),[m
[32m+[m[32m }[m
[32m+[m[32m}[m
[32m+[m
[32m+[m[32mfn adaptive_search_truncate(value: &str, query: &str, max_chars: usize) -> String {[m
[32m+[m[32m let value_len = visible_len(value);[m
[32m+[m[32m if value_len <= max_chars {[m
[32m+[m[32m return value.to_string();[m
[32m+[m[32m }[m
[32m+[m
[32m+[m[32m if max_chars <= 1 {[m
[32m+[m[32m return "…".to_string();[m
[32m+[m[32m }[m
[32m+[m
[32m+[m[32m let Some(match_start) = find_case_insensitive_char_index(value, query) else {[m
[32m+[m[32m return truncate_with_ellipsis(value, max_chars);[m
[32m+[m[32m };[m
[32m+[m
[32m+[m[32m if match_start < max_chars.saturating_sub(1) {[m
[32m+[m[32m return truncate_with_ellipsis(value, max_chars);[m
[32m+[m[32m }[m
[32m+[m
[32m+[m[32m let available = max_chars.saturating_sub(2);[m
[32m+[m[32m if available == 0 {[m
[32m+[m[32m return "…".to_string();[m
[32m+[m[32m }[m
[32m+[m
[32m+[m[32m let query_len = visible_len(query).max(1);[m
[32m+[m[32m let context_before = available.saturating_sub(query_len) / 2;[m
[32m+[m[32m let start = match_start[m
[32m+[m[32m .saturating_sub(context_before)[m
[32m+[m[32m .min(value_len.saturating_sub(available));[m
[32m+[m[32m let end = start + available;[m
[32m+[m
[32m+[m[32m if start == 0 {[m
[32m+[m[32m return truncate_with_ellipsis(value, max_chars);[m
[32m+[m[32m }[m
[32m+[m
[32m+[m[32m if end >= value_len {[m
[32m+[m[32m let tail_width = max_chars.saturating_sub(1);[m
[32m+[m[32m let tail_start = value_len.saturating_sub(tail_width);[m
[32m+[m[32m let tail = value.chars().skip(tail_start).collect::<String>();[m
[32m+[m[32m return format!("…{tail}");[m
[32m+[m[32m }[m
[32m+[m
[32m+[m[32m let window = value[m
[32m+[m[32m .chars()[m
[32m+[m[32m .skip(start)[m
[32m+[m[32m .take(available)[m
[32m+[m[32m .collect::<String>();[m
[32m+[m[32m format!("…{window}…")[m
[32m+[m[32m}[m
[32m+[m
[32m+[m[32mfn find_case_insensitive_char_index(value: &str, query: &str) -> Option<usize> {[m
[32m+[m[32m let value_lower = value.to_lowercase();[m
[32m+[m[32m let query_lower = query.to_lowercase();[m
[32m+[m[32m let byte_index = value_lower.find(&query_lower)?;[m
[32m+[m[32m Some(value_lower[..byte_index].chars().count())[m
[32m+[m[32m}[m
[32m+[m
fn truncate_with_ellipsis(value: &str, max_chars: usize) -> String {[m
let value_len = visible_len(value);[m
if value_len <= max_chars {[m
[36m@@ -253,4 +321,52 @@[m [mmod tests {[m
"US · 128k"[m
);[m
}[m
[32m+[m
[32m+[m[32m #[test][m
[32m+[m[32m fn search_truncation_keeps_matching_suffix_visible() {[m
[32m+[m[32m let truncated = truncate_station_name([m
[32m+[m[32m "SomaFM Deep Space One Underground 80s",[m
[32m+[m[32m Some("Underground"),[m
[32m+[m[32m 18,[m
[32m+[m[32m );[m
[32m+[m
[32m+[m[32m assert!(truncated.starts_with('…'));[m
[32m+[m[32m assert!(truncated.contains("Underground"));[m
[32m+[m[32m }[m
[32m+[m
[32m+[m[32m #[test][m
[32m+[m[32m fn search_truncation_keeps_matching_tail_visible() {[m
[32m+[m[32m let truncated = truncate_station_name("SomaFM Deep Space One", Some("Space One"), 12);[m
[32m+[m
[32m+[m[32m assert!(truncated.starts_with('…'));[m
[32m+[m[32m assert!(truncated.contains("Space One"));[m
[32m+[m[32m }[m
[32m+[m
[32m+[m[32m #[test][m
[32m+[m[32m fn search_truncation_falls_back_when_query_is_blank() {[m
[32m+[m[32m assert_eq!([m
[32m+[m[32m truncate_station_name("SomaFM Deep Space One", Some(" "), 10),[m
[32m+[m[32m "SomaFM De…"[m
[32m+[m[32m );[m
[32m+[m[32m }[m
[32m+[m
[32m+[m[32m #[test][m
[32m+[m[32m fn search_truncation_falls_back_when_query_is_missing() {[m
[32m+[m[32m assert_eq!([m
[32m+[m[32m truncate_station_name("SomaFM Deep Space One", Some("jazz"), 10),[m
[32m+[m[32m "SomaFM De…"[m
[32m+[m[32m );[m
[32m+[m[32m }[m
[32m+[m
[32m+[m[32m #[test][m
[32m+[m[32m fn search_truncation_handles_tiny_width() {[m
[32m+[m[32m assert_eq!(truncate_station_name("SomaFM", Some("fm"), 1), "…");[m
[32m+[m[32m }[m
[32m+[m
[32m+[m[32m #[test][m
[32m+[m[32m fn search_truncation_is_unicode_safe() {[m
[32m+[m[32m let truncated = truncate_station_name("São Paulo Rádio Underground", Some("rádio"), 10);[m
[32m+[m
[32m+[m[32m assert!(truncated.contains("Rádio"));[m
[32m+[m[32m }[m
}[m